Skip to main content

media_pp\elements\source/
audio_mixer.rs

1use std::{
2    collections::{HashMap, VecDeque},
3    sync::{
4        Arc, Mutex, Weak,
5        atomic::{AtomicU64, Ordering},
6    },
7    thread,
8    time::{Duration, Instant},
9};
10
11use crate::pp_log::{PpLog, pp_error, pp_info};
12use ffmpeg_next as ffmpeg;
13use thiserror::Error as ThisError;
14
15use crate::{
16    buffer::MediaBuffer,
17    bus::{Bus, BusEvent},
18    control::{ControlMsg, ControlReceiver, drain_control},
19    element::{Element, ElementType, Sink, Source, SourceElement, element_pp_log},
20    error::Result,
21    pad::SrcPad,
22    schedule::ActiveTimeline,
23};
24
25/// How often [`AudioMixer::run`] mixes and emits a combined frame — same
26/// role as [`crate::elements::DxgiCaptureSource`]'s own `POLL_GRANULARITY`/
27/// `crate::elements::WasapiCaptureSource`'s `POLL_INTERVAL`: bounds `Stop`
28/// latency and sets the mixer's own output granularity.
29const TICK_INTERVAL: Duration = Duration::from_millis(20);
30
31/// Errors specific to `AudioMixer`. Converts into the crate-wide `Error`
32/// via `?` (see [`crate::error::Error`]).
33#[derive(Debug, ThisError)]
34pub enum AudioMixerError {
35    #[error("ffmpeg error: {0}")]
36    Ffmpeg(#[from] ffmpeg_next::Error),
37
38    #[error("AudioMixer doesn't support seeking a live mix")]
39    SeekUnsupported,
40
41    #[error("AudioMixer inputs only accept Audio or Eos buffers, got {0}")]
42    UnsupportedBuffer(&'static str),
43}
44
45/// Construction-time options for [`AudioMixer::new`] — the mixer's fixed
46/// *output* format. Every input is resampled to match this on the way in
47/// (see `InputBuffer::push`); the mixer never adapts to whatever an
48/// input happens to produce.
49#[derive(Debug, Clone, Copy)]
50pub struct AudioMixerOptions {
51    pub sample_rate: u32,
52    pub channels: u16,
53}
54
55/// One input's own resampler and accumulated (already-resampled,
56/// interleaved `f32`) samples, waiting to be drained by the next
57/// [`AudioMixer::mix_tick`]. The resampler is built lazily from the first
58/// frame this input ever sees (its `format`/`channel_layout`/`rate`
59/// self-describe — no need for [`MixerHandle::add_source`] to be told
60/// this upfront).
61struct InputBuffer {
62    /// Identity of this particular registration. The name can be reused,
63    /// but an older sink must not be allowed to touch its replacement.
64    id: u64,
65    resampler: Option<ffmpeg::software::resampling::Context>,
66    samples: VecDeque<f32>,
67    /// Set once this input's `Eos` arrives — [`AudioMixer::mix_tick`]
68    /// drops the input entirely once it's both `eos` and fully drained,
69    /// same as a `Tee` branch dropping out once removed. Unlike a fixed
70    /// two-track muxer, `AudioMixer` has no fixed input count to wait on:
71    /// one input reaching `Eos` just means the mix continues without it.
72    eos: bool,
73}
74
75impl InputBuffer {
76    fn push(
77        &mut self,
78        frame: &ffmpeg::frame::Audio,
79        target_format: ffmpeg::format::Sample,
80        target_layout: ffmpeg::ChannelLayout,
81        target_rate: u32,
82    ) -> std::result::Result<(), AudioMixerError> {
83        let resampler = match &mut self.resampler {
84            Some(resampler) => resampler,
85            None => {
86                let resampler = ffmpeg::software::resampling::Context::get(
87                    frame.format(),
88                    frame.channel_layout(),
89                    frame.rate(),
90                    target_format,
91                    target_layout,
92                    target_rate,
93                )?;
94                self.resampler.insert(resampler)
95            }
96        };
97        let mut output = ffmpeg::frame::Audio::empty();
98        resampler.run(frame, &mut output)?;
99        // Raw bytes, not `plane::<f32>(0)`: `ffmpeg_next`'s `plane::<T>()`
100        // always returns exactly `output.samples()` elements of type `T`,
101        // which for **packed multi-channel** data (this mixer's own fixed
102        // `Sample::F32(Packed)` target — see `AudioMixer::new`) covers only
103        // the first `samples()` of the real `samples() * channels`
104        // interleaved scalars actually in the buffer, silently dropping
105        // every channel past the first once `target_layout` has more than
106        // one. Same fix, and the same root cause, as
107        // `crate::elements::SwAudioEncoder`'s own `absorb_resampled`
108        // (found while building that element — this call predates it).
109        let samples = output.samples();
110        let channels = target_layout.channels() as usize;
111        let bytes = &output.data(0)[..samples * channels * 4];
112        let interleaved =
113            unsafe { std::slice::from_raw_parts(bytes.as_ptr() as *const f32, bytes.len() / 4) };
114        self.samples.extend(interleaved.iter().copied());
115        Ok(())
116    }
117}
118
119/// Shared state between [`AudioMixer`] and every [`MixerHandle`]/
120/// [`MixerInputSink`] derived from it — just the input map, behind one
121/// lock (same granularity [`crate::elements::Tee`]'s own `TeeShared::pads`
122/// uses: one lock for the whole collection, not one per entry, since a mix
123/// tick already needs to visit every input together anyway).
124struct MixerShared {
125    inputs: Mutex<HashMap<Arc<str>, InputBuffer>>,
126    /// Issues a distinct identity for every `add_source` call, including
127    /// replacements registered under an existing name.
128    next_input_id: AtomicU64,
129}
130
131/// A cheaply-cloneable handle for adding or removing an [`AudioMixer`]'s
132/// input sources while the pipeline is running — the mirror image of
133/// [`crate::elements::TeeHandle`]: `Tee` lets you attach/detach *outputs*
134/// from another thread; this lets you attach/detach *inputs*. Keeps only a
135/// [`Weak`] reference for the same reason `TeeHandle` does: retaining a
136/// handle after the mixer's own pipeline finishes must not keep its
137/// internal state alive forever, and every operation becomes a harmless
138/// no-op once the mixer is gone.
139#[derive(Clone)]
140pub struct MixerHandle {
141    shared: Weak<MixerShared>,
142    sample_rate: u32,
143    format: ffmpeg::format::Sample,
144    channel_layout: ffmpeg::ChannelLayout,
145}
146
147impl MixerHandle {
148    /// Registers a new input under `name` and returns a [`Sink`] to use as
149    /// a detached branch terminal. Build and attach it inside that source's
150    /// own `Pipeline::new` wiring closure — a
151    /// *different* pipeline/thread than this mixer's own, which is exactly
152    /// the point). `None` once the mixer itself is gone. Calling this
153    /// again with a name already in use replaces that input outright
154    /// (whatever it had buffered is dropped) rather than erroring — same
155    /// "just do what was asked" spirit as `HashMap::insert`. A sink from
156    /// the previous registration then becomes inert: its data, `Eos`, and
157    /// `Stop` cannot affect the replacement sharing its name.
158    ///
159    /// The input endpoint appears in the upstream source pipeline's graph
160    /// when attached through [`crate::element::Context::attach`]. The graph
161    /// intentionally does not invent a cross-pipeline edge to the mixer.
162    pub fn add_source(&self, name: impl Into<String>) -> Option<Box<dyn Sink>> {
163        let shared = self.shared.upgrade()?;
164        let name: Arc<str> = name.into().into();
165        let id = shared.next_input_id.fetch_add(1, Ordering::Relaxed);
166        shared.inputs.lock().unwrap().insert(
167            name.clone(),
168            InputBuffer {
169                id,
170                resampler: None,
171                samples: VecDeque::new(),
172                eos: false,
173            },
174        );
175        Some(Box::new(MixerInputSink {
176            name: name.clone(),
177            id,
178            pp_log: element_pp_log(ElementType::AudioMixer, &name, None),
179            shared: self.shared.clone(),
180            target_format: self.format,
181            target_layout: self.channel_layout,
182            target_rate: self.sample_rate,
183        }))
184    }
185
186    /// Drops `name`'s input immediately, discarding whatever it had
187    /// buffered — a no-op if `name` isn't currently registered, or the
188    /// mixer is gone.
189    pub fn remove_source(&self, name: &str) {
190        if let Some(shared) = self.shared.upgrade() {
191            shared.inputs.lock().unwrap().remove(name);
192        }
193    }
194
195    pub fn source_count(&self) -> usize {
196        self.shared
197            .upgrade()
198            .map(|shared| shared.inputs.lock().unwrap().len())
199            .unwrap_or(0)
200    }
201}
202
203/// One [`AudioMixer`] input, returned by [`MixerHandle::add_source`].
204/// Resamples every incoming frame to the mixer's fixed output format and
205/// appends it to this input's own buffer — the actual summing happens
206/// later, on [`AudioMixer::run`]'s own thread, not here. `consume` runs on
207/// whatever thread is driving the *upstream* source this got linked to
208/// (a different pipeline's own thread, in the normal case), so every
209/// access to the shared input map goes through `MixerShared`'s lock.
210pub struct MixerInputSink {
211    pp_log: PpLog,
212    name: Arc<str>,
213    /// Identity returned by the corresponding `add_source` call. Compared
214    /// with the map entry before every mutation so a stale sink cannot
215    /// write to or remove a same-name replacement.
216    id: u64,
217    shared: Weak<MixerShared>,
218    target_format: ffmpeg::format::Sample,
219    target_layout: ffmpeg::ChannelLayout,
220    target_rate: u32,
221}
222
223// SAFETY: `ffmpeg::ChannelLayout` wraps `AVChannelLayout`, which carries a
224// non-`Send` custom-layout pointer only for `AV_CHANNEL_ORDER_CUSTOM`
225// layouts. Every `ChannelLayout` here comes from `ChannelLayout::default`
226// (see `AudioMixer::new`) — a plain native layout, that pointer always
227// null — so there's nothing thread-unsafe actually being sent.
228unsafe impl Send for MixerInputSink {}
229
230impl Element for MixerInputSink {
231    fn name(&self) -> Arc<str> {
232        self.name.clone()
233    }
234
235    fn element_type(&self) -> ElementType {
236        ElementType::AudioMixer
237    }
238
239    fn pp_log(&self) -> &PpLog {
240        &self.pp_log
241    }
242
243    fn pp_log_mut(&mut self) -> &mut PpLog {
244        &mut self.pp_log
245    }
246}
247
248impl Sink for MixerInputSink {
249    fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
250        let Some(shared) = self.shared.upgrade() else {
251            return Ok(()); // mixer's own pipeline already ended — nothing to feed
252        };
253        match buf {
254            MediaBuffer::Audio(frame) => {
255                let mut inputs = shared.inputs.lock().unwrap();
256                if let Some(input) = inputs.get_mut(&self.name)
257                    && input.id == self.id
258                {
259                    input.push(
260                        &frame,
261                        self.target_format,
262                        self.target_layout,
263                        self.target_rate,
264                    )?;
265                }
266                // Absent means `remove_source` raced ahead of this frame;
267                // an ID mismatch means this name was replaced. Dropping
268                // the frame is correct in both cases.
269            }
270            MediaBuffer::Eos => {
271                let mut inputs = shared.inputs.lock().unwrap();
272                if let Some(input) = inputs.get_mut(&self.name)
273                    && input.id == self.id
274                {
275                    input.eos = true;
276                }
277            }
278            other => {
279                pp_error!(self, "unsupported buffer: expected Audio or Eos");
280                return Err(AudioMixerError::UnsupportedBuffer(other.kind()).into());
281            }
282        }
283        Ok(())
284    }
285
286    /// No downstream of its own to cascade to — this is a leaf input slot,
287    /// not a passthrough. `Stop` removes this input immediately, same as
288    /// [`MixerHandle::remove_source`]: `Stop` means abandon now, not drain
289    /// to a natural `Eos` (see `ControlMsg::Stop`'s own docs), and for a
290    /// live capture source — `WasapiCaptureSource`
291    /// included — `Stop` is the *only* shutdown signal that ever arrives;
292    /// it never reaches `Eos` on its own. Relying on `Eos` alone to clean
293    /// up (as an earlier version of this did) left a stale entry in
294    /// `shared.inputs` forever whenever a caller stopped its capture
295    /// pipeline normally instead of remembering to call
296    /// `MixerHandle::remove_source` by hand. `Pause`/`Resume`/`Seek` need
297    /// no handling here — this input has no thread or queue of its own to
298    /// freeze/resume, and a live capture source doesn't seek. Removal is
299    /// conditional on the registration ID: a late `Stop` from a replaced
300    /// sink must not remove the newer input using the same name.
301    fn control(&mut self, msg: ControlMsg) -> Result<()> {
302        if msg == ControlMsg::Stop
303            && let Some(shared) = self.shared.upgrade()
304        {
305            let mut inputs = shared.inputs.lock().unwrap();
306            if inputs
307                .get(&self.name)
308                .is_some_and(|input| input.id == self.id)
309            {
310                inputs.remove(&self.name);
311            }
312        }
313        Ok(())
314    }
315}
316
317/// Sums an arbitrary, dynamically-changing number of audio sources into
318/// one output stream — the structural mirror of [`crate::elements::Tee`]:
319/// `Tee` is one input fanned out to a dynamic set of outputs behind a
320/// lock; `AudioMixer` is a dynamic set of inputs (added/removed via
321/// [`MixerHandle`], from whatever thread each one's own source pipeline
322/// runs on) summed into one output. Unlike `Tee`, which is a passive
323/// [`Sink`] driven entirely by whatever calls `consume`, `AudioMixer` has
324/// to drive itself: it's a [`SourceElement`] with its own `run` thread,
325/// ticking every `TICK_INTERVAL` to sum however many samples each
326/// currently-attached input has ready — because mixing has to keep
327/// producing *something* on a steady clock even when some (or all) inputs
328/// have gone quiet, the same reason
329/// `WasapiCaptureSource` synthesizes silence for gaps
330/// rather than just emitting nothing.
331///
332/// Every input is resampled to this mixer's own fixed
333/// `sample_rate`/`channels` (always `Sample::F32(Packed)` internally —
334/// float headroom during summation, same reason real mixing consoles
335/// work in float even when everything else is integer PCM) — an input
336/// short on samples for a given tick contributes silence for the
337/// shortfall rather than blocking the whole mix. Samples are summed and
338/// **hard-clipped** to `[-1.0, 1.0]`, not averaged: two or three sources
339/// is the expected case, where clipping is rare, and averaging would
340/// quietly lower the whole mix's volume every time a source count
341/// changes — a caller who wants headroom can lower an individual input's
342/// gain before it ever reaches the mixer (not implemented — nothing needs
343/// it yet).
344///
345/// `pts` is a plain, always-continuous sample count (see
346/// [`AudioMixer::time_base`]), advancing in lockstep with wall-clock time
347/// regardless of which/how many inputs are actually contributing at any
348/// moment.
349///
350/// Runs until `Stop` — never reaches `Eos` on its own, same as every
351/// other live source in this crate; an individual input reaching `Eos` or
352/// being removed just drops out of future ticks, it doesn't end the mix.
353pub struct AudioMixer {
354    pp_log: PpLog,
355    name: Arc<str>,
356    shared: Arc<MixerShared>,
357    pad: SrcPad,
358    sample_rate: u32,
359    format: ffmpeg::format::Sample,
360    channel_layout: ffmpeg::ChannelLayout,
361    channels: u16,
362    /// Cumulative sample count across every emitted frame — see
363    /// [`AudioMixer::time_base`].
364    samples_emitted: i64,
365}
366
367// SAFETY: see `MixerInputSink`'s own `unsafe impl Send` docs — same
368// reasoning, `channel_layout` here is always `ChannelLayout::default`'s
369// plain native layout.
370unsafe impl Send for AudioMixer {}
371
372impl AudioMixer {
373    /// Starts with no inputs — add some via the returned [`MixerHandle`]
374    /// before (or any time after) wiring `AudioMixer` into a
375    /// [`crate::pipeline::Pipeline`] (`Pipeline::new` registers it as that
376    /// pipeline's own source automatically, same as any other
377    /// [`SourceElement`] — no [`crate::element::Context`] needed here,
378    /// unlike [`crate::elements::TeeBuilder::new`], since `AudioMixer` has no
379    /// chains of its own for a handle to build).
380    pub fn new(name: impl Into<String>, options: AudioMixerOptions) -> (Self, MixerHandle) {
381        let name: Arc<str> = name.into().into();
382        let pp_log = element_pp_log(ElementType::AudioMixer, &name, None);
383        pp_info!(
384            pp_log: &pp_log,
385            "created: {}Hz, {} channel(s)",
386            options.sample_rate,
387            options.channels
388        );
389        let format = ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Packed);
390        let channel_layout = ffmpeg::ChannelLayout::default(options.channels as i32);
391        let shared = Arc::new(MixerShared {
392            inputs: Mutex::new(HashMap::new()),
393            next_input_id: AtomicU64::new(0),
394        });
395        let pad = SrcPad::new(format!("{name}_src"));
396        (
397            Self {
398                name: name.clone(),
399                pp_log,
400                shared: shared.clone(),
401                pad,
402                sample_rate: options.sample_rate,
403                format,
404                channel_layout,
405                channels: options.channels,
406                samples_emitted: 0,
407            },
408            MixerHandle {
409                shared: Arc::downgrade(&shared),
410                sample_rate: options.sample_rate,
411                format,
412                channel_layout,
413            },
414        )
415    }
416
417    /// The unit each emitted frame's `pts` is expressed in.
418    pub fn time_base(&self) -> ffmpeg::Rational {
419        ffmpeg::Rational::new(1, self.sample_rate as i32)
420    }
421
422    /// Sums however many samples are needed to keep `samples_emitted` in
423    /// lockstep with `elapsed` (a no-op if nothing's owed yet — same
424    /// wall-clock-deficit shape as
425    /// [`crate::elements::WasapiCaptureSource::fill_silence_gap`], just
426    /// summing real contributions from every input instead of emitting
427    /// pure silence). `elapsed` already excludes time spent frozen inside
428    /// `Pause` (see [`crate::schedule::ActiveTimeline`]) so a `Pause`/
429    /// `Resume` pair doesn't get summed as a burst of owed samples the
430    /// moment playback resumes. Drops any input that's both `eos` and
431    /// fully drained — it contributed its last real samples on a previous
432    /// tick and has nothing left to give.
433    fn mix_tick(&mut self, elapsed: Duration, bus: &Bus) {
434        let channels = self.channels as usize;
435        let expected = (elapsed.as_secs_f64() * self.sample_rate as f64) as i64;
436        let needed = (expected - self.samples_emitted).max(0) as usize;
437        if needed == 0 {
438            return;
439        }
440        let mut mixed = vec![0f32; needed * channels];
441        {
442            let mut inputs = self.shared.inputs.lock().unwrap();
443            inputs.retain(|_, input| !(input.eos && input.samples.is_empty()));
444            for input in inputs.values_mut() {
445                let take = mixed.len().min(input.samples.len());
446                for (slot, sample) in mixed.iter_mut().zip(input.samples.iter()) {
447                    *slot += *sample;
448                }
449                input.samples.drain(0..take);
450            }
451        }
452        for sample in &mut mixed {
453            *sample = sample.clamp(-1.0, 1.0);
454        }
455
456        let mut frame = ffmpeg::frame::Audio::new(self.format, needed, self.channel_layout);
457        frame.set_rate(self.sample_rate);
458        let bytes = unsafe {
459            std::slice::from_raw_parts(mixed.as_ptr() as *const u8, std::mem::size_of_val(&*mixed))
460        };
461        // `frame.data_mut(0)`'s length is FFmpeg's own padded linesize,
462        // not necessarily `mixed.len() * 4` exactly — only ever write that
463        // tight amount (same bound `frame.plane::<T>()` itself reads via
464        // `samples()`), never assume the destination's full length
465        // matches `bytes` (see `WasapiCaptureSource::build_frame`'s own
466        // identical fix).
467        frame.data_mut(0)[..bytes.len()].copy_from_slice(bytes);
468        frame.set_pts(Some(self.samples_emitted));
469        self.samples_emitted += needed as i64;
470
471        if let Err(error) = self.pad.push(MediaBuffer::Audio(Arc::new(frame))) {
472            bus.post(
473                &self.pp_log,
474                BusEvent::Error {
475                    element_type: ElementType::AudioMixer,
476                    name: self.name.clone(),
477                    error,
478                },
479            );
480        }
481    }
482}
483
484impl Element for AudioMixer {
485    fn name(&self) -> Arc<str> {
486        self.name.clone()
487    }
488
489    fn element_type(&self) -> ElementType {
490        ElementType::AudioMixer
491    }
492
493    fn pp_log(&self) -> &PpLog {
494        &self.pp_log
495    }
496
497    fn pp_log_mut(&mut self) -> &mut PpLog {
498        &mut self.pp_log
499    }
500}
501
502impl Source for AudioMixer {
503    fn src_pads(&mut self) -> &mut [SrcPad] {
504        std::slice::from_mut(&mut self.pad)
505    }
506}
507
508impl SourceElement for AudioMixer {
509    fn run(&mut self, control: &ControlReceiver, bus: &Bus) -> Result<()> {
510        pp_info!(self, "started");
511        let mut timeline = ActiveTimeline::new(Instant::now());
512        loop {
513            let outcome = drain_control(control, self, bus)?;
514            if outcome.stopped {
515                pp_info!(self, "stopped");
516                return Ok(());
517            }
518            timeline.account_pause(outcome.paused_for);
519            thread::sleep(TICK_INTERVAL);
520            self.mix_tick(timeline.elapsed(Instant::now()), bus);
521        }
522    }
523
524    fn seek(&mut self, _target: Duration) -> Result<Duration> {
525        Err(AudioMixerError::SeekUnsupported.into())
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use std::sync::{
532        Mutex as StdMutex,
533        atomic::{AtomicBool, Ordering},
534    };
535
536    use crate::pp_log::PpLog;
537
538    use super::*;
539    use crate::pipeline::Pipeline;
540
541    fn constant_frame(value: f32, samples: usize, rate: u32) -> ffmpeg::frame::Audio {
542        let mut frame = ffmpeg::frame::Audio::new(
543            ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Packed),
544            samples,
545            ffmpeg::ChannelLayout::default(1),
546        );
547        frame.set_rate(rate);
548        frame.plane_mut::<f32>(0).fill(value);
549        frame
550    }
551
552    struct RecordingSink {
553        pp_log: PpLog,
554        seen: Arc<StdMutex<Vec<f32>>>,
555    }
556
557    impl Element for RecordingSink {
558        fn name(&self) -> Arc<str> {
559            "recorder".into()
560        }
561        fn element_type(&self) -> ElementType {
562            ElementType::Other
563        }
564        fn pp_log(&self) -> &PpLog {
565            &self.pp_log
566        }
567        fn pp_log_mut(&mut self) -> &mut PpLog {
568            &mut self.pp_log
569        }
570    }
571
572    impl Sink for RecordingSink {
573        fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
574            if let MediaBuffer::Audio(frame) = buf
575                && frame.samples() > 0
576            {
577                self.seen.lock().unwrap().push(frame.plane::<f32>(0)[0]);
578            }
579            Ok(())
580        }
581        fn control(&mut self, _msg: ControlMsg) -> Result<()> {
582            Ok(())
583        }
584    }
585
586    fn constant_stereo_frame(
587        left: f32,
588        right: f32,
589        samples: usize,
590        rate: u32,
591    ) -> ffmpeg::frame::Audio {
592        let mut frame = ffmpeg::frame::Audio::new(
593            ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Packed),
594            samples,
595            ffmpeg::ChannelLayout::default(2),
596        );
597        frame.set_rate(rate);
598        let bytes = frame.data_mut(0);
599        let floats =
600            unsafe { std::slice::from_raw_parts_mut(bytes.as_mut_ptr() as *mut f32, samples * 2) };
601        for pair in floats.chunks_mut(2) {
602            pair[0] = left;
603            pair[1] = right;
604        }
605        frame
606    }
607
608    struct StereoRecordingSink {
609        pp_log: PpLog,
610        seen: Arc<StdMutex<Vec<(f32, f32)>>>,
611    }
612
613    impl Element for StereoRecordingSink {
614        fn name(&self) -> Arc<str> {
615            "stereo-recorder".into()
616        }
617        fn element_type(&self) -> ElementType {
618            ElementType::Other
619        }
620        fn pp_log(&self) -> &PpLog {
621            &self.pp_log
622        }
623        fn pp_log_mut(&mut self) -> &mut PpLog {
624            &mut self.pp_log
625        }
626    }
627
628    impl Sink for StereoRecordingSink {
629        fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
630            if let MediaBuffer::Audio(frame) = buf
631                && frame.samples() > 0
632            {
633                // Raw bytes, not `plane::<f32>(0)`, for the same reason
634                // `InputBuffer::push` above does: `AudioMixer`'s output is
635                // packed multi-channel, and `plane::<T>()` only ever
636                // returns `samples()` elements regardless of channel
637                // count — reading channel 1 through it would silently
638                // read the wrong offset (still inside channel 0's data),
639                // not the second channel.
640                let samples = frame.samples();
641                let bytes = &frame.data(0)[..samples * 2 * 4];
642                let floats = unsafe {
643                    std::slice::from_raw_parts(bytes.as_ptr() as *const f32, samples * 2)
644                };
645                self.seen.lock().unwrap().push((floats[0], floats[1]));
646            }
647            Ok(())
648        }
649        fn control(&mut self, _msg: ControlMsg) -> Result<()> {
650            Ok(())
651        }
652    }
653
654    /// Regression test for the packed-multichannel `InputBuffer::push` bug
655    /// (see the comment there): two stereo inputs, each with distinct,
656    /// asymmetric L/R values, should sum per-channel without the channels
657    /// bleeding into each other or silently dropping to zero. Before the
658    /// fix, `plane::<f32>(0)` under-read the resampled buffer (only
659    /// `samples()` interleaved scalars instead of `samples() * channels`),
660    /// which desynced every input's channel alignment.
661    #[test]
662    fn mixes_stereo_sources_without_channel_corruption() {
663        let (mixer, handle) = AudioMixer::new(
664            "mixer",
665            AudioMixerOptions {
666                sample_rate: 48000,
667                channels: 2,
668            },
669        );
670        let seen = Arc::new(StdMutex::new(Vec::new()));
671        let sink = StereoRecordingSink {
672            seen: seen.clone(),
673            pp_log: element_pp_log(ElementType::Other, "stereo-recorder", None),
674        };
675
676        let pipeline = Pipeline::new("mixer-stereo-test", mixer, |source, ctx| {
677            let branch = ctx.branch().to(Box::new(sink))?;
678            ctx.attach(source, 0, branch)?;
679            Ok(())
680        })
681        .expect("test pipeline wiring must succeed");
682        pipeline.run();
683
684        let mut input_a = handle.add_source("a").expect("mixer still alive");
685        let mut input_b = handle.add_source("b").expect("mixer still alive");
686
687        let stop = Arc::new(AtomicBool::new(false));
688        let feeder_stop = stop.clone();
689        let feeder = std::thread::spawn(move || {
690            while !feeder_stop.load(Ordering::Relaxed) {
691                let _ = input_a.consume(MediaBuffer::Audio(Arc::new(constant_stereo_frame(
692                    0.2, -0.1, 480, 48000,
693                ))));
694                let _ = input_b.consume(MediaBuffer::Audio(Arc::new(constant_stereo_frame(
695                    0.1, -0.2, 480, 48000,
696                ))));
697                std::thread::sleep(Duration::from_millis(10));
698            }
699        });
700
701        std::thread::sleep(Duration::from_millis(300));
702        stop.store(true, Ordering::Relaxed);
703        feeder.join().unwrap();
704        pipeline.stop();
705        pipeline.bus().log_events();
706
707        let seen = seen.lock().unwrap();
708        assert!(
709            seen.len() > 5,
710            "expected several mixed frames, got {seen:?}"
711        );
712        let steady = &seen[3..seen.len() - 2];
713        for &(left, right) in steady {
714            assert!(
715                (left - 0.3).abs() < 0.01,
716                "expected left channel ~0.3, got {left} in {seen:?}"
717            );
718            assert!(
719                (right - -0.3).abs() < 0.01,
720                "expected right channel ~-0.3, got {right} in {seen:?}"
721            );
722        }
723    }
724
725    /// Two inputs, each pushing a constant `0.6` from their own thread
726    /// (standing in for two independent capture pipelines), should sum to
727    /// `1.2` and get hard-clipped to `1.0` — verifies resampling-on-first-
728    /// frame, cross-thread `consume`, summation, and clipping all work
729    /// together, not just in isolation.
730    #[test]
731    fn mixes_two_sources_and_hard_clips() {
732        let (mixer, handle) = AudioMixer::new(
733            "mixer",
734            AudioMixerOptions {
735                sample_rate: 48000,
736                channels: 1,
737            },
738        );
739        let seen = Arc::new(StdMutex::new(Vec::new()));
740        let sink = RecordingSink {
741            seen: seen.clone(),
742            pp_log: element_pp_log(ElementType::Other, "recorder", None),
743        };
744
745        let pipeline = Pipeline::new("mixer-test", mixer, |source, ctx| {
746            let branch = ctx.branch().to(Box::new(sink))?;
747            ctx.attach(source, 0, branch)?;
748            Ok(())
749        })
750        .expect("test pipeline wiring must succeed");
751        pipeline.run();
752
753        let mut input_a = handle.add_source("a").expect("mixer still alive");
754        let mut input_b = handle.add_source("b").expect("mixer still alive");
755        assert_eq!(handle.source_count(), 2);
756
757        let stop = Arc::new(AtomicBool::new(false));
758        let feeder_stop = stop.clone();
759        let feeder = std::thread::spawn(move || {
760            while !feeder_stop.load(Ordering::Relaxed) {
761                let _ = input_a.consume(MediaBuffer::Audio(Arc::new(constant_frame(
762                    0.6, 480, 48000,
763                ))));
764                let _ = input_b.consume(MediaBuffer::Audio(Arc::new(constant_frame(
765                    0.6, 480, 48000,
766                ))));
767                std::thread::sleep(Duration::from_millis(10));
768            }
769        });
770
771        std::thread::sleep(Duration::from_millis(300));
772        stop.store(true, Ordering::Relaxed);
773        feeder.join().unwrap();
774        pipeline.stop();
775        pipeline.bus().log_events();
776
777        let seen = seen.lock().unwrap();
778        assert!(
779            seen.len() > 5,
780            "expected several mixed frames, got {seen:?}"
781        );
782        // Skip the first few ticks (the feeder thread may not have caught
783        // up yet) and the last couple (ticks after the feeder stopped but
784        // before `pipeline.stop()` landed correctly drain to silence) —
785        // check the steady state in between is clipped to 1.0.
786        let steady = &seen[3..seen.len() - 2];
787        for &value in steady {
788            assert!(
789                (value - 1.0).abs() < 0.01,
790                "expected hard-clipped ~1.0, got {value} in {seen:?}"
791            );
792        }
793    }
794
795    #[test]
796    fn removed_source_stops_contributing() {
797        let (mixer, handle) = AudioMixer::new(
798            "mixer",
799            AudioMixerOptions {
800                sample_rate: 48000,
801                channels: 1,
802            },
803        );
804        let seen = Arc::new(StdMutex::new(Vec::new()));
805        let sink = RecordingSink {
806            seen: seen.clone(),
807            pp_log: element_pp_log(ElementType::Other, "recorder", None),
808        };
809        let pipeline = Pipeline::new("mixer-test-2", mixer, |source, ctx| {
810            let branch = ctx.branch().to(Box::new(sink))?;
811            ctx.attach(source, 0, branch)?;
812            Ok(())
813        })
814        .expect("test pipeline wiring must succeed");
815        pipeline.run();
816
817        let mut input_a = handle.add_source("a").unwrap();
818        input_a
819            .consume(MediaBuffer::Audio(Arc::new(constant_frame(
820                0.5, 480, 48000,
821            ))))
822            .unwrap();
823        std::thread::sleep(Duration::from_millis(100));
824        handle.remove_source("a");
825        assert_eq!(handle.source_count(), 0);
826        seen.lock().unwrap().clear();
827
828        std::thread::sleep(Duration::from_millis(100));
829        pipeline.stop();
830        pipeline.bus().log_events();
831
832        assert!(
833            seen.lock().unwrap().iter().all(|&v| v == 0.0),
834            "removed source must not keep contributing: {:?}",
835            *seen.lock().unwrap()
836        );
837    }
838
839    /// Regression test: a capture pipeline ending via `Stop` — the only
840    /// shutdown signal a live source like `WasapiCaptureSource` ever sends,
841    /// since it never reaches `Eos` on its own — used to leave a stale
842    /// entry in the mixer's input map forever, because only `Eos` cleared
843    /// it. `Sink::control` is what a `Queue`/`Pipeline` actually calls on
844    /// `Stop` (mirrored by hand here, since this input isn't wired into a
845    /// real second `Pipeline` in this test), not `consume`.
846    #[test]
847    fn stopped_source_is_removed_without_an_explicit_remove_source_call() {
848        let (mixer, handle) = AudioMixer::new(
849            "mixer",
850            AudioMixerOptions {
851                sample_rate: 48000,
852                channels: 1,
853            },
854        );
855        let seen = Arc::new(StdMutex::new(Vec::new()));
856        let sink = RecordingSink {
857            seen: seen.clone(),
858            pp_log: element_pp_log(ElementType::Other, "recorder", None),
859        };
860        let pipeline = Pipeline::new("mixer-test-3", mixer, |source, ctx| {
861            let branch = ctx.branch().to(Box::new(sink))?;
862            ctx.attach(source, 0, branch)?;
863            Ok(())
864        })
865        .expect("test pipeline wiring must succeed");
866        pipeline.run();
867
868        let mut input_a = handle.add_source("a").unwrap();
869        input_a
870            .consume(MediaBuffer::Audio(Arc::new(constant_frame(
871                0.5, 480, 48000,
872            ))))
873            .unwrap();
874        assert_eq!(handle.source_count(), 1);
875
876        // What a `Queue`/`Pipeline` actually calls on this input's own
877        // `Sink` when its upstream capture pipeline is stopped — never
878        // `consume(Eos)`, since `WasapiCaptureSource` doesn't send one.
879        input_a.control(ControlMsg::Stop).unwrap();
880
881        assert_eq!(
882            handle.source_count(),
883            0,
884            "Stop should remove the input immediately, same as remove_source"
885        );
886
887        pipeline.stop();
888        pipeline.bus().log_events();
889    }
890
891    /// Re-registering a name replaces its input buffer, but callers may
892    /// still hold the sink returned for the old registration. Every late
893    /// operation through that stale sink must be inert rather than being
894    /// redirected to (or deleting) the replacement merely because the map
895    /// key is the same.
896    #[test]
897    fn replacing_an_input_by_name_invalidates_the_stale_sink() {
898        let (_mixer, handle) = AudioMixer::new(
899            "mixer",
900            AudioMixerOptions {
901                sample_rate: 48000,
902                channels: 1,
903            },
904        );
905        let mut stale = handle.add_source("mic").expect("mixer still alive");
906        let mut current = handle.add_source("mic").expect("mixer still alive");
907        assert_eq!(handle.source_count(), 1);
908
909        stale
910            .consume(MediaBuffer::Audio(Arc::new(constant_frame(
911                0.75, 480, 48000,
912            ))))
913            .unwrap();
914        stale.consume(MediaBuffer::Eos).unwrap();
915        stale.control(ControlMsg::Stop).unwrap();
916
917        assert_eq!(
918            handle.source_count(),
919            1,
920            "a stale sink's Stop must not remove its replacement"
921        );
922        let shared = handle.shared.upgrade().expect("mixer still alive");
923        {
924            let inputs = shared.inputs.lock().unwrap();
925            let input = inputs.get("mic").expect("replacement remains registered");
926            assert!(
927                input.resampler.is_none() && input.samples.is_empty(),
928                "stale audio must not enter the replacement buffer"
929            );
930            assert!(!input.eos, "stale Eos must not mark the replacement ended");
931        }
932
933        // The current sink still owns the registration and therefore
934        // remains fully functional.
935        current
936            .consume(MediaBuffer::Audio(Arc::new(constant_frame(
937                0.25, 480, 48000,
938            ))))
939            .unwrap();
940        current.consume(MediaBuffer::Eos).unwrap();
941        {
942            let inputs = shared.inputs.lock().unwrap();
943            let input = inputs.get("mic").expect("replacement remains registered");
944            assert!(input.resampler.is_some(), "current audio was not accepted");
945            assert!(input.eos, "current Eos was not accepted");
946        }
947
948        current.control(ControlMsg::Stop).unwrap();
949        assert_eq!(handle.source_count(), 0);
950    }
951
952    /// A misrouted `Packet`/`Video` buffer used to be silently logged and
953    /// dropped — no `BusEvent::Error`, no way for a misconfigured pipeline
954    /// to ever find out. Matches the typed-error pattern every other
955    /// `Sink` in this codebase already uses for a wrong `MediaBuffer`
956    /// variant (e.g. `Mp4MuxerStreamSink`).
957    #[test]
958    fn rejects_buffers_that_are_neither_audio_nor_eos() {
959        let (mixer, handle) = AudioMixer::new(
960            "mixer",
961            AudioMixerOptions {
962                sample_rate: 48000,
963                channels: 2,
964            },
965        );
966        let mut input = handle.add_source("a").expect("mixer still alive");
967
968        let error = input
969            .consume(MediaBuffer::Packet(Arc::new(ffmpeg::Packet::empty())))
970            .expect_err("a Packet buffer must be rejected, not silently dropped");
971        assert!(
972            matches!(
973                error,
974                crate::error::Error::AudioMixerError(AudioMixerError::UnsupportedBuffer("Packet"))
975            ),
976            "unexpected error: {error:?}"
977        );
978
979        drop(mixer);
980    }
981}